fix(datadog): restore LCP/FCP reporting by keeping the initial_load view alive - #1642
Conversation
… again Under `trackViewsManually` the RUM SDK stays stopped until the first `startView`, adopts that call's options as its one `initial_load` view, and turns every later call into a `route_change` view. Only an `initial_load` view runs `trackInitialViewMetrics`, so it is the only view that can ever carry LCP or FCP. Studio called `startView` twice on boot — once in `useDatadog`, then again in `useOnRouteLoadTracker` — so the initial view was ended microseconds later and its paint metrics were thrown away. Measured against the real SDK bundle, the two calls land 0.3ms apart and the initial_load event ships with dom_complete but no lcp and no fcp, matching production exactly: of 200 initial_load views, dom_complete 17, fcp 2, lcp 0. Drop the `useDatadog` call and leave the single one to `useOnRouteLoadTracker`, which mounts on the root route and so runs on every cloud route. That also fixes the view name: `useDatadog` used `window.location.pathname`, which is permanently `/` under the hash router, so all 880 initial_load views in a week were named `/` regardless of the route actually loaded. Refs #1570 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…trim comments Cross-model review (cursor-composer) noted the #1570 guard rendered `useDatadog` alone, so it only caught the specific regression of re-adding that call — a second `startView` introduced anywhere else in the boot tree would leave it green while production went back to zero vitals. Mount both hooks the way production does (App → StudioCloud) and assert exactly one `startView`, which is the invariant that actually matters; keep the isolated case to narrow a failure to the hook that regressed. Also pin the expected view name to the translated route so a revert to pathname-based naming fails CI instead of silently restoring permanently-`/` names, and trim the comments in both files to the one non-obvious SDK constraint per the repo's zero-new-comments default (codex nit). Both new assertions are mutation-verified: re-adding the deleted `startView` turns the tree-level test and the isolated test red. Refs #1570 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-2 review (gemini) noted the suite proved the boot case but nothing about later navigations, so a regression that stopped emitting `route_change` views would go unnoticed. Assert the tracker emits a further named view per href change. Comment trim per the repeated nit from both lenses: drop the issue-number narration and the restated test rationale, keeping only the two non-obvious constraints (the module-scope `enabled` read, and the production nesting the boot test mirrors). Refs #1570 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The invariant is documented in AGENTS.md; the nesting is visible in the code. Third repeat of the same review nit. Refs #1570 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request resolves an issue where Datadog RUM's Core Web Vitals (LCP/FCP) were not being tracked due to multiple startView calls during boot. The initial startView call has been removed from useDatadog so that useOnRouteLoadTracker is the sole owner of the initial view. Documentation has been added to AGENTS.md to explain this behavior, and a new test suite has been introduced. The review feedback suggests stabilizing the mocked useRouter hook in the tests to prevent unnecessary effect re-runs caused by unstable object references.
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||
gemini-code-assist: the mock returned a fresh `useRouter()` object per render, and the tracker's effect lists `router` in its deps — so the effect re-fired on every render and the navigation assertions held even without `location.href` as a dependency. The real `useRouter` returns a stable reference, so the mock was also unfaithful. Instantiate the router once with a getter for `state`, and assert that a re-render which changes nothing produces no view. That assertion fails against the old unstable mock, so the fix stays guarded. Refs #1570 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… identities The lesson from this PR's own escaped review finding: an unstable mocked `useRouter` made an effect-counting test pass for the wrong reason, and would have passed with the dependency removed entirely. Records the getter pattern and the no-op-rerender assertion that catches it. Refs #1570 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The existing suite mocks `@tanstack/react-router` entirely, so nothing proved the invariant the fix actually rests on: that the tracker is mounted at the root route and therefore runs on every cloud page load. If someone moves `useOnRouteLoadTracker` into a narrower layout, RUM silently stops starting any view — a no-data failure nothing alerts on. Boots the real `rootRoute` and the real `dashboardLayout` guard on a signed-out deep link and asserts exactly one `startView`, named `/sign-in/`. Mutation-verified both ways: dropping the tracker from `StudioCloud` gives 0 calls, and disabling the redirect guard moves the name to the deep link. It also settles a question the PR description had left to the reviewer — a boot-time redirect does not re-fire the initial view, because the guard resolves during the router's initial load and the root component never commits the deep-link location. Refs #1570 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whether a boot redirect costs the `initial_load` view turns entirely on whether auth is known synchronously, which is not obvious from either file: `beforeLoad` redirects only once `!isLoading && !user`, and `getAllConnections()` reports `isLoading: false` up front unless the `Studio:PotentiallyAuthenticated` record carries an `OverallAppSignIn` entry. Ordinary signed-out deep link: one view. Expired session: two, a round trip apart. Verified against the real router. Refs #1570 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
codex (pre-push): the note credited `getAllConnections()` with reporting `isLoading: true` for a persisted session. It does not — that value comes from `getConnectionById`. `getAllConnections()` synthesizes an entry only when the `Studio:PotentiallyAuthenticated` record lacks `OverallAppSignIn`; with the entry present it returns the record untouched and the key is absent, so `beforeLoad` short-circuits on `auth &&` rather than on `isLoading`. Same two outcomes, right mechanism. Also trims the narrating half of the new test's header comment, which both legs flagged. Refs #1570 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DavidCockerill
left a comment
There was a problem hiding this comment.
Approving. No findings. The diagnosis is the valuable part and it is exactly right.
Verified nothing is lost by the deletion, which is the thing to check when removing a call that passed options: the deleted startView carried service: 'studio' and version: VITE_STUDIO_VERSION, the surviving call in useOnRouteLoadTracker carries both identically, and datadogRum.init sets service, env and version globally anyway (datadog.ts:29-31). No attribution is dropped.
Exactly one startView remains in non-test source, and the choice of which to keep is argued rather than arbitrary — keeping useDatadog's would restore vitals equally well but leave every initial_load view named /, because it derived names from window.location.pathname while Studio uses hash routing. Naming the alternative and its cost is what makes that reviewable.
The invariant is pinned by tests, and that is the part that matters most here. datadog.test.tsx asserts startView is called exactly once, that it isn't called when disabled, and the resulting sequence of view names, plus a dedicated datadogBootView.test.tsx. The bug was a second caller, so a future third caller now fails CI instead of silently killing paint metrics for another two months.
One note for anyone reading this later: this is unrelated to the RUM redaction work in #1632 — the regression dates to 2026-07-04 and predates it by a month, so it should not be read as fallout from that change.
Leaving a comment where the deleted call used to be, explaining why there is no startView there, is the right instinct — it means the obvious-looking "add a startView on boot" change can't be made innocently.
— DAIvid (Claude Opus 5)
Studio has reported no LCP or FCP since 2026-07-04 (#1570) because it called
datadogRum.startViewtwice on boot, and the second call destroyed the only view that can carry paint metrics. This removes the redundant call, which also fixes every initial page load being attributed to the view name/.Under
trackViewsManually: truethe RUM SDK stays stopped until the firststartView, adopts that call's options as its singleinitial_loadview, and turns every later call into aroute_changeview (preStartRum.tstryStartRum,trackViews.tsstartView). Only aninitial_loadview runstrackInitialViewMetrics, so it is the only view that can ever carry LCP or FCP.useDatadoganduseOnRouteLoadTrackerboth calledstartView, so the initial view was ended microseconds after it began.For the human reviewer
Which of the two
startViewcalls to delete. KeptuseOnRouteLoadTracker's, deleteduseDatadog's. The alternative — keepuseDatadog's and gate the tracker to fire only on subsequent routes — restores vitals equally well but leaves everyinitial_loadview named/, becauseuseDatadognamed views fromwindow.location.pathnameand Studio uses hash routing. DeletinguseDatadog's call fixes both defects with one edit. Fully reversible; a change of mind costs one commit.The boot-redirect concern an earlier draft of this description left to you is now settled, and it splits in two. Measured against the real TanStack router in jsdom, not reasoned about:
authStore.getAllConnections()synthesizes{ user: null, isLoading: false }forOverallAppSignInwhenever theStudio:PotentiallyAuthenticatedrecord has no entry for it, sodashboardLayout.beforeLoadthrows its redirect during the router's initial load and the root component never commits the deep-link location. Exactly onestartView, named/sign-in/.datadogBootView.test.tsxnow pins this.getAllConnections()returns the record untouched and the key is simply absent, sobeforeLoadshort-circuits onauth &&rather than onisLoading. The deep link renders, then auth resolves andAppRouted'srouter.invalidate()drives the redirect. The judgment: that second view is a genuine navigation, and RUM ending the current view on navigation is correct behaviour, not a bug — suppressing it would cost realroute_changetracking to buy a metric. Note the window here is a network round trip, not the 0.3 ms of the bug being fixed, and this PR takes such a session from three views to two.The residual risk if you disagree: expired-session sessions may still under-report vitals. Say the word and I will extend this PR.
RUM now starts on first route render rather than on
Appmount — and there is a narrow telemetry consequence. This follows from (1): the firststartViewis what starts the SDK, and the tracker lives inStudioCloud, the root route component. Gemini flagged this as a data-loss major on the theory that some routes render outside the cloud root; that is refuted —rootRouteTreeisrootRoute.addChildren([...]), so every route renders insideStudioCloud, includingdefaultNotFoundComponentanddefaultErrorComponent. What remains is genuinely narrow: if the root component or the router itself fails catastrophically before rendering, no view ever starts and that session reports nothing, where previouslyuseDatadogwould already have started RUM from outside the router. If you want that closed, the clean way isuseDatadogkeeping astartViewand the tracker callingsetViewName()on its first run instead ofstartView— the SDK exposes it (rumPublicApi.ts:118) and it renames the initial view without ending it. I did not do it because it adds first-run state for a failure mode I cannot reproduce, but it is the strictly-better design if you judge the boot-error window worth it.StudioLocaldoes not call the tracker, so local Studio never starts a view. Pre-existing and correct —enabledis!import.meta.env.DEV && !isLocalStudio, so RUM is fully disabled there and no view would have been sent anyway. Flagged only because the asymmetry reads like an oversight in the diff.Addressed from PR review: gemini-code-assist found the mocked
useRouterreturned a fresh object per render. That was not cosmetic — because the tracker's effect listsrouterin its deps, the effect re-fired on every render and the subsequent-navigation assertion was vacuous, holding even withoutlocation.hrefas a dependency. Fixed inff3635b0with a single stable router identity, plus an assertion that a no-op re-render produces no view; that assertion fails against the old mock, so it stays guarded.Addressed from pre-push review: codex caught that the new
AGENTS.mdnote creditedgetAllConnections()with reportingisLoading: truefor a persisted session. It does not — that value comes fromgetConnectionById, a different API. Corrected ine53ca443; the two outcomes were right, the mechanism was not.Declined, three rounds running: a nit that the diff's five comments narrate. I audited each against the zero-new-comments default and kept all five, because each is a constraint a future reader would otherwise delete along with the guard it protects — the
datadog.tscomment marks the absence of astartViewat the exact line someone would re-add one; the router-mock comment is why the mock is a single instance with a getter, which "simplifying" back to a literal makes the navigation tests vacuous again;datadog.test.tsx:41is why the suite uses a dynamic import (a top-level one leavesenabledfalse and every assertion passes for nothing);:117is why a re-render that changes nothing is not dead code; anddatadogBootView.test.tsx:26is why the real routes there are load-bearing. Overturn any of them cheaply if you disagree — the reasons are all one line.Verified and closed, recorded here so you don't re-derive them. Both re-checked this round against the installed
@datadog/browser-rum-core@7.8.0, not the published source:startViewbeforeuseDatadog'sinit) is safe.onReady(callback) { callback() }—browser-core/cjs/boot/init.js:8, synchronous. AstartViewarriving beforeinitis buffered intobufferApiCalls, recorded asfirstStartViewCall, and adopted asinitialViewOptionswhentryStartRum()later succeeds —browser-rum-core/cjs/boot/preStartRum.js:121-133and:33-39. This is the SDK's designed path fortrackViewsManually, not a tolerated accident.datadogBootView.test.tsxfails to catchuseDatadogreintroducing a bootstartViewis true of that file and irrelevant — the two-hook tree case is the first test indatadog.test.tsx, and it is the one that goes red under exactly that mutation. The two files guard different invariants on purpose.AppRouted's context-and-invalidate()wiring in the harness, which tests the harness rather than the product. I measured it with a throwaway probe instead — two views, matching theAGENTS.mdnote — and shipped only the test whose reals are all production code.routertolocation.href— a pre-existing line this PR doesn't touch, with a hypothetical trigger, so it stayed out under YAGNI.Verification
Route: live reproduction against the real SDK, plus a real-router regression test — the change is not observable through the e2e suite (RUM is disabled in dev and test builds, and no e2e spec touches it).
Served the shipped
@datadog/browser-rum@7.8.0bundle over HTTP and replicated Studio's boot sequence (initin a deferred callback,startViewinsideonReady, then a secondstartViewin the same flush), with abeforeSendthat captured each assembled event and returnedfalseso nothing reached Datadog:stagebehaviour): the calls land att=78.2msandt=78.5ms— 0.3ms apart. Theinitial_loadevent ships withdom_complete: 79msand nolcp, nofcp, then aroute_changeview takes over.initial_loadviews over 7 days,dom_complete17,fcp2,lcp0. All 880initial_loadviews that week are named/— one facet bucket — whileroute_changeviews carry proper route names.Regression tests.
datadog.test.tsx(4 cases, mocked router): mounts both hooks in the production nesting and asserts exactly onestartView; pins its name to the translated route; keeps an isolateduseDatadogcase to localise a failure; and asserts a further named view per subsequent navigation.datadogBootView.test.tsx(1 case, real router) boots the realrootRouteand the realdashboardLayoutguard on a signed-out deep link and asserts onestartViewnamed/sign-in/— this is the file that guards the invariant the whole fix rests on, that the tracker is still mounted at the root route.All mutation-verified rather than merely green: re-adding the deleted block turns the tree-level and isolated cases red; dropping
useOnRouteLoadTracker()fromStudioCloudtakes the boot test to0 calls; and disabling the redirect guard moves its asserted name to the deep link, so the name assertion is load-bearing too.Gates (Node 24.19.0, all exit 0, re-run after every review round):
vitest run323 files / 2626 passed,tsc -b,oxlint,dprint check, andpnpm test:e2e:docker(4 passed, 4 skipped — the skipped specs need roundtrip credentials). Script mapping: Studio has notest:unit:main/test:unit:resources/test:integration:all; the equivalents aretest(vitest) andtest:e2e:docker(Playwright).No documentation PR. Nothing user-facing changes — this is internal telemetry wiring. The durable findings went to
AGENTS.mdinstead, which is where the next agent to touch RUM will look.Not proven, and the thing to watch post-merge: that the fix restores LCP/FCP end-to-end. Every browser surface available locally reports
visibilityState: 'hidden', which emits zero paint and LCP entries, andtrackFirstHiddenwould discard them anyway — so vitals read as absent whether the fix works or not. The cheap confirmation is@view.largest_contentful_paintcoverage oninitial_loadviews after this deploys: it should go from 0% to a non-trivial share. Do not close #1570 on merge — close it on that measurement, which is why this description says "Refs" and not "Fixes". Note #1405's baseline predates this and needs re-framing rather than just fresh data — its "/view" was every deep-link entry conflated into one bucket.Coverage caveat. The Harper
domainadjudicator has failed on every round of this PR (exit-1, zero-byte log — a known failure on this machine), so no outside finding here was ever machine-adjudicated; I triaged them against the installed SDK and the route tree, which is why several are recorded as refuted/verified rather than fixed. Both Cursor lenses are structurally unavailable to this PR:cursor-reviewrefuses any diff that touchesAGENTS.md, and this one does. What did run independently iscodex+gemini, both on the final code.Complexity: medium
Review-Coverage: authored=claude; ran=gemini,codex; declined=cursor-grok,cursor-composer,domain; rounds=8 @ e53ca44
Human-Review-Need: 4 @ e53ca44